package com.opennews.openplatform.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.opennews.openplatform.jooq.DSLContextWrapper;
import com.opennews.openplatform.myspringbootcore.common.constant.ServerError;
import com.opennews.openplatform.myspringbootcore.security.*;
import com.opennews.openplatform.myspringbootcore.util.SharedUtil;
import lombok.RequiredArgsConstructor;
import org.jooq.DSLContext;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;

import static com.opennews.openplatform.entity.Tables.ACCOUNT_GROUP_USER;
import static com.opennews.openplatform.entity.Tables.USER;

@RequiredArgsConstructor
@Service
public class AuthenticationService {
    private final DSLContext dslContext;
    private final DSLContextWrapper dslContextWrapper;
    private final Environment env;
    private final ObjectMapper objectMapper;
    private final TokenManager tokenManager;
    private final UserService userService;
    private final UserActiveTokenService userActiveTokenService;

    /**
     * Authenticates user based on username and password.
     *
     * @param username    : String of username.
     * @param password    : String of user raw password.
     * @param ipAddress   : String of client request ip address.
     * @param deviceInfo  : Map instance which contains device related info.
     * @param systemInfo: The map contains user device system info.
     * @return The map instance which contains keys below.
     * myUserDetails: Instance of MyUserDetails.
     * errorCode: String of server error code.
     */
    public Map<String, Object> authenticateByCredential(String username, String password, String ipAddress, Map<String, String> deviceInfo, Map<String, String> systemInfo) {
        MyUserDetails myUserDetails = new MyUserDetails();
        String errorCode = "";

        if (userService.validatePassword(username, password)) {
            Map<String, Object> user = userService.queryStatus(username);

            // Checks on user each status.
            if (!(Boolean) user.get("enabled")) {
                errorCode = ServerError.ERROR_USER_ACCOUNT_DISABLED;
            } else if ((Boolean) user.get("accountExpired")) {
                errorCode = ServerError.ERROR_USER_ACCOUNT_EXPIRED;
            } else if ((Boolean) user.get("accountLocked")) {
                errorCode = ServerError.ERROR_USER_ACCOUNT_LOCKED;
            } else if ((Boolean) user.get("passwordExpired")) {
                errorCode = ServerError.ERROR_USER_PASSWORD_EXPIRED;
            } else {
                myUserDetails = generateUserDetails(username, deviceInfo.get("id"));

                if (!userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo)) {
                    errorCode = ServerError.ERROR_USER_DEVICE_LOGIN_DISABLED;
                }
            }
        } else {
            // If the user input is not correct.
            // Actually we did not give info like 'user not found' or 'password is incorrect'.
            // This is for void brute force attack. If the attacker does NOT know the login id or password is correct that would make the attach more difficult.
            errorCode = ServerError.ERROR_USER_ACCOUNT_BAD_CREDENTIAL;
        }

        return Map.of(
                "myUserDetails", myUserDetails,
                "errorCode", errorCode
        );
    }

    /**
     * Authenticates user based on username and auth code.
     *
     * @param username   : String of username.
     * @param authCode   : String of auth code.
     * @param deviceInfo : Map instance which contains device related info.
     * @return The map instance which contains keys below.
     * myUserDetails: Instance of MyUserDetails.
     * errorCode: String of server error code.
     */
    public Map<String, Object> authenticateByAuthCode(String username, String authCode, Map<String, String> deviceInfo) {
        MyUserDetails myUserDetails = new MyUserDetails();
        String errorCode = "";

        return Map.of(
                "myUserDetails", myUserDetails,
                "errorCode", errorCode
        );
    }

    /**
     * Refreshes access token with valid refresh token.
     *
     * @param refreshToken : String of refresh token.
     * @param ipAddress    : String of client request ip address.
     * @param deviceInfo   : Map instance which contains device related info.
     * @param systemInfo:  The map contains user device system info.
     * @return The map instance which contains keys below.
     * myUserDetails: Instance of MyUserDetails.
     * errorCode: String of server error code.
     */
    public Map<String, Object> refreshToken(String refreshToken, String ipAddress, Map<String, String> deviceInfo, Map<String, String> systemInfo) {
        MyUserDetails myUserDetails = new MyUserDetails();
        String errorCode = "";
        ParsedToken parsedToken = tokenManager.validateToken(refreshToken, env.getProperty("jwt.signingKey"));
        String deviceId = null;

        if (deviceInfo != null) {
            deviceId = deviceInfo.get("id");
        }

        // If token is valid we still need to check if it is still active.
        if (parsedToken.valid) {
            parsedToken.valid = userActiveTokenService.isActiveRefreshToken(parsedToken.claims.username, deviceId, refreshToken);
        }

        if (parsedToken.valid) {
            // Generates new details.
            myUserDetails = generateUserDetails(parsedToken.claims.username, deviceId);

            // But keeps refresh token unchanged.
            myUserDetails.refreshToken = refreshToken;

            // Updates new token as active.
            userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo);
        } else {
            errorCode = ServerError.ERROR_USER_ACCOUNT_NOT_AUTHORIZED;
        }

        return Map.of(
                "myUserDetails", myUserDetails,
                "errorCode", errorCode
        );
    }

    public void logout(String token) {
        userActiveTokenService.deleteByAccessToken(token);
    }

    /**
     * Switches user to account group by its id.
     *
     * @param accountGroupId : String of account group id.
     * @param username       : String of username.
     * @param ipAddress      : String of client request ip address.
     * @param deviceInfo     : Map instance which contains device related info.
     * @param systemInfo:    The map contains user device system info.
     * @return The map instance which contains keys below.
     * myUserDetails: Instance of MyUserDetails.
     * errorCode: String of server error code.
     */
    public MyUserDetails switchAccountGroup(String accountGroupId, String userId, String username, String ipAddress, Map<String, String> deviceInfo, Map<String, String> systemInfo) {
        MyUserDetails myUserDetails = new MyUserDetails();

        // Checks if user has this account group permission.
        if (dslContextWrapper.count(ACCOUNT_GROUP_USER, ACCOUNT_GROUP_USER.ACCOUNT_GROUP_ID.eq(accountGroupId)
                .and(ACCOUNT_GROUP_USER.USER_ID.eq(userId))) == 1) {
            // Updates user current account group to provided one.
            dslContext.update(USER)
                    .set(USER.ACCOUNT_GROUP_ID, accountGroupId)
                    .where(USER.ID.eq(userId))
                    .execute();

            // Generates MyUserDetails and token.
            myUserDetails = generateUserDetails(username, deviceInfo.get("id"));
            userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo);
        }

        return myUserDetails;
    }

    public MyUserDetails generateUserDetails(String username, String deviceId) {
        MyUserDetails myUserDetails = new MyUserDetails();

        if (!SharedUtil.isNullOrEmpty(username)) {
            String signingKey = env.getProperty("jwt.signingKey");
            int expiration = Integer.parseInt(env.getProperty("jwt.expiration"));

            // Queries user details from db.
            Map<String, Object> userDetails = userService.queryDetails(username);

            // For refresh token which does not need roles and it never expires.
            MyClaims claims = new MyClaims();
            claims.accountGroupId = userDetails.get("accountGroupId").toString();
            claims.id = userDetails.get("id").toString();
            claims.username = username;
            claims.deviceId = deviceId;

            // Generates refresh token. 0 means it never expires.
            String refreshTokenString = tokenManager.generateToken(claims, signingKey, 0);

            // Generates list of security.FullRole based on list of map item.
            List<FullRole> fullRoles = new ArrayList<>();

            FullRole fullRole;

            // For claims usage.
            List<String> roleNames = new ArrayList<>();

            for (Map<String, String> role : (List<Map<String, String>>) userDetails.get("fullRoles")) {
                // Converts map item to FullRole.
                fullRole = objectMapper.convertValue(role, FullRole.class);

                // Adds it to role list.
                fullRoles.add(fullRole);

                // Gets role name for claims.
                roleNames.add(fullRole.authority);
            }

            // For access token which will expires in particular duration.
            claims.roles = roleNames;

            // Generates access token.
            String accessTokenString = tokenManager.generateToken(claims, signingKey, expiration);

            // Prepares MyUserDetails data.
            myUserDetails.accountGroupId = userDetails.get("accountGroupId").toString();
            myUserDetails.id = userDetails.get("id").toString();
            myUserDetails.username = userDetails.get("username").toString();
            myUserDetails.fullName = userDetails.get("fullName").toString();
            myUserDetails.avatarUrl = userDetails.get("avatarUrl").toString();
            myUserDetails.isAccountGroupCreator = Boolean.parseBoolean(userDetails.get("isAccountGroupCreator").toString());
            myUserDetails.bearer = "bearer";
            myUserDetails.roles = claims.roles;
            myUserDetails.fullRoles = fullRoles;
            myUserDetails.accessToken = accessTokenString;
            myUserDetails.refreshToken = refreshTokenString;
            myUserDetails.expiresIn = expiration;

            // Without toInstant() the value looks like Thu Apr 25 06:06:52 UTC 2024
            // With toInstant() the value looks like 2024-04-25T06:06:14Z
            // Both works on clientside but toInstant() produces value in China friendly standard UTC format.
            myUserDetails.generatedAt = Date.from(Instant.ofEpochSecond(claims.issuedAt)).toInstant().toString();
            myUserDetails.expiredAt = Date.from(Instant.ofEpochSecond(claims.expiresAt)).toInstant().toString();
        }

        return myUserDetails;
    }
}
